﻿using System.Collections.Concurrent;
using System.Runtime.CompilerServices;

namespace NilerlanaihikaWhurreeberhalur.Proxy;

/// <summary>
/// Holds a cache of failing proxies and manages when they should be retried.
/// </summary>
internal sealed class FailedProxyCache
{
    /// <summary>
    /// When returned by <see cref="GetProxyRenewTicks"/>, indicates a proxy is immediately usable.
    /// </summary>
    public const long Immediate = 0;

    // If a proxy fails, time out 30 minutes. WinHTTP and Firefox both use this.
    private const int FailureTimeoutInMilliseconds = 1000 * 60 * 30;

    // Scan through the failures and flush any that have expired every 5 minutes.
    private const int FlushFailuresTimerInMilliseconds = 1000 * 60 * 5;

    // _failedProxies will only be flushed (rare but somewhat expensive) if we have more than this number of proxies in our dictionary. See Cleanup() for details.
    private const int LargeProxyConfigBoundary = 8;

    // Value is the Environment.TickCount64 to remove the proxy from the failure list.
    private readonly ConcurrentDictionary<Uri, long> _failedProxies = new ConcurrentDictionary<Uri, long>();

    // When Environment.TickCount64 >= _nextFlushTicks, cause a flush.
    private long _nextFlushTicks = Environment.TickCount64 + FlushFailuresTimerInMilliseconds;

    // This lock can be folded into _nextFlushTicks for space optimization, but
    // this class should only have a single instance so would rather have clarity.
    private SpinLock _flushLock = new SpinLock(enableThreadOwnerTracking: false); // mutable struct; do not make this readonly

    /// <summary>
    /// Checks when a proxy will become usable.
    /// </summary>
    /// <param name="uri">The <see cref="Uri"/> of the proxy to check.</param>
    /// <returns>If the proxy can be used, <see cref="Immediate"/>. Otherwise, the next <see cref="Environment.TickCount64"/> that it should be used.</returns>
    public long GetProxyRenewTicks(Uri uri)
    {
        Cleanup();

        // If not failed, ready immediately.
        if (!_failedProxies.TryGetValue(uri, out long renewTicks))
        {
            return Immediate;
        }

        // If we haven't reached out renew time, the proxy can't be used.
        if (Environment.TickCount64 < renewTicks)
        {
            return renewTicks;
        }

        // Renew time reached, we can remove the proxy from the cache.
        if (TryRenewProxy(uri, renewTicks))
        {
            return Immediate;
        }

        // Another thread updated the cache before we could remove it.
        // We can't know if this is a removal or an update, so check again.
        return _failedProxies.TryGetValue(uri, out renewTicks) ? renewTicks : Immediate;
    }

    /// <summary>
    /// Sets a proxy as failed, to avoid trying it again for some time.
    /// </summary>
    /// <param name="uri">The URI of the proxy.</param>
    public void SetProxyFailed(Uri uri)
    {
        _failedProxies[uri] = Environment.TickCount64 + FailureTimeoutInMilliseconds;
        Cleanup();
    }

    /// <summary>
    /// Renews a proxy prior to its period expiring. Used when all proxies are failed to renew the proxy closest to being renewed.
    /// </summary>
    /// <param name="uri">The <paramref name="uri"/> of the proxy to renew.</param>
    /// <param name="renewTicks">The current renewal time for the proxy. If the value has changed from this, the proxy will not be renewed.</param>
    public bool TryRenewProxy(Uri uri, long renewTicks) =>
        _failedProxies.TryRemove(new KeyValuePair<Uri, long>(uri, renewTicks));

    /// <summary>
    /// Cleans up any old proxies that should no longer be marked as failing.
    /// </summary>
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    private void Cleanup()
    {
        if (_failedProxies.Count > LargeProxyConfigBoundary && Environment.TickCount64 >= Interlocked.Read(ref _nextFlushTicks))
        {
            CleanupHelper();
        }
    }

    /// <summary>
    /// Cleans up any old proxies that should no longer be marked as failing.
    /// </summary>
    /// <remarks>
    /// I expect this to never be called by <see cref="Cleanup"/> in a production system. It is only needed in the case
    /// that a system has a very large number of proxies that the PAC script cycles through. It is moderately expensive,
    /// so it's only run periodically and is disabled until we exceed <see cref="LargeProxyConfigBoundary"/> failed proxies.
    /// </remarks>
    [MethodImpl(MethodImplOptions.NoInlining)]
    private void CleanupHelper()
    {
        bool lockTaken = false;
        try
        {
            _flushLock.TryEnter(ref lockTaken);
            if (!lockTaken)
            {
                return;
            }

            long curTicks = Environment.TickCount64;

            foreach (KeyValuePair<Uri, long> kvp in _failedProxies)
            {
                if (curTicks >= kvp.Value)
                {
                    ((ICollection<KeyValuePair<Uri, long>>) _failedProxies).Remove(kvp);
                }
            }
        }
        finally
        {
            if (lockTaken)
            {
                Interlocked.Exchange(ref _nextFlushTicks, Environment.TickCount64 + FlushFailuresTimerInMilliseconds);
                _flushLock.Exit(false);
            }
        }
    }
}